iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0

系列:「從單一 agent 到多 agent 集群,再到接進我的生活」;不設天數,第 12 篇(上)

這篇是 Day 12 的上半:16 條紅怎麼從「五種」重分成「四種」、每種怎麼先寫一條會紅的測試、
看它紅在預期的那一行才動 lib、拿到綠燈紀錄;以及第 16 條(route ratchet 的下限)為什麼
今天決定不修、留紅等誰裁定。下半從全量收工開始——稅、閘、兩次撞用量上限、CI 接線、
勘誤與執行紀錄——是另一篇。紅證測試的 HEAD 是 2d062f40,唯一一次 lib 改動的 commit
f3fcb84f(10:24)。

Day 12 紅證計分板:五條預測、09:52 紅證、10:45 修後 targeted、16:24 全量——只有留案的 route ratchet 一條紅

前言

Day 11 收工時的數字:修之前全量 45 紅;ConnectInfo fail-closed(414db8c4)之後,
同一批 8 個 binary 剩 16 紅,五種[Day 11 已勘誤:四種]。修之後的全量 20:13 起跑,寫完文章時還沒收工。

修之後的全量收工了。早上 08:36,跑了 12 小時 23 分。數字在下一節。

今天不是把 16 條修掉。是一種一種來,每種先紅證:先寫一條會紅的測試,看它紅在
預期的那一行,才動 lib。做完拿到一行可以被別人核對的綠燈紀錄;然後讓那盞燈有人看。

Day 7 立的規矩是「先量再修」。Day 11 量出來 16 條、五種——但那五種是按測試的名字
分的,不是按 panic 的那一行分的。今天第一件事是把 16 條的 panic 行逐條再讀一次。讀完,
五種變四種,其中兩種在 Day 11 寫錯了原因。

還有一件事決定今天所有事的順序:這台 Mac 的 Gatekeeper。早上以為的規則是「lib 一改,
202 支 binary 全部重 link、全部重審」,所以會紅的測試先進 tree(lib 一個字不動)、lib 的改動
集中成一次 commit。順序是對的,理由是錯的:09:58 的 F0 紅證量到 core/build.rs每一次
cargo 都重編 lib——跟有沒有改 lib 無關。這個 crate 的 lib 在這台機器上一次都沒 Fresh 過,
直到 10:16。那一段留在下一篇的「F0」那節。

到 14:32,16 條紅裡的 15 條都綠了,而且是同一個 lib commit 之後的一次 targeted 一起綠的;
第 16 條留著,等裁定——這篇寫到這裡結束。中間有六個小時什麼都沒推進——session 兩次撞上
用量上限,10 個 agent 死在半路。那段也留給下一篇,在「兩次撞上限」那節。

修之後的數字

Day 11 估「Gatekeeper 80 分鐘」。實際:

START 2026-09-04 20:13:44  HEAD=414db8c4  core_clean=0
Finished `test` profile in 12m 47s
Starting 3593 tests across 202 binaries (70 tests skipped)
Summary [3410.455s] 3593 tests run: 3577 passed (5 slow, 2 leaky), 16 failed, 70 skipped
EXIT=100
END   2026-09-05 08:36:25

整輪 12 小時 23 分:編譯 12 分 47 秒、實跑 56 分 50 秒、中間 11 小時 13 分
--list——nextest 對 202 支剛 link 出來的 binary 逐支列舉,每支停在 S 狀態等
syspolicyd。log 本身沒有 --list 的時間戳;11h13m 是 20:26:31(編完)到 07:39:34
(junit 的 timestamp,測試開跑)算出來的,W5 查核過。Day 11 同一步是 81 分鐘。

兩輪並排:

修之前(Day 11) 修之後
HEAD b7377c13 414db8c4
跑 / 過 / 紅 / 跳 3591 / 3546 / 45 / 70 3593 / 3577 / 16 / 70
binary 201 202(多一支放行側測試)
編譯 18m35s 12m47s
--list(Gatekeeper) ~81 min 11 h 13 m
實跑 176 s 3410 s
起 → 訖 18:17 → 20:02(1h45m) 20:13 → 08:36(12h23m)

45 → 16。ConnectInfo 那 31 條清掉了,這是 414db8c4 唯一宣稱的事。

而且 16 條的名字跟 Day 11 晚上 8 個 binary 的 targeted 跑出來的 16 條一模一樣:
12 條 skill_rpc_skills(含 latency 那條的 TRY 1/TRY 2)、2 條 lib
serve::squad_dispatch_tests、1 條 p9_the_shell_cannot_pin_itself、1 條
the_app_only_calls_routes_that_exist。targeted 從起跑到收工不到 6 分鐘(20:04:47 → 20:10:31,
共 5m44s:57 秒編譯、3 分 24 秒 Gatekeeper(Day 11 原文寫「約 4 分鐘」,重算後偏低)、83 秒
實跑),預測了全量 12 小時的答案。
全量沒有告訴我任何 targeted 沒說的事。它買到的只有一行別人可以核對的紀錄——junit 在
core/target/nextest/default/junit.xml,當時 mtime 08:36,已備份成
junit-full-after-20260905-0836.xml(原始檔案後來被 10:45 targeted 與 14:32 Phase D 覆寫)。

一件沒解釋的:實跑從 176 秒變 3410 秒。修之前那輪 nextest 標 SLOW 的測試有 5 條(當時沒設
slow-timeout,門檻是預設 60 秒);修之後那輪 junit 裡 time > 60 s 的有 348 條(這輪門檻改成
120 秒,nextest.toml:26;Summary 的「5 slow」是超過 120 秒的那 5 條);p9_the_shell_cannot_pin_itself
那條 0.062 秒變 98.5 秒。這段時間(07:40–08:36)session 正撞用量上限(07:15–09:30),tick #2 的
8 個 agent 有幾個還活著沒查。是 syspolicyd 對每次 exec 收稅、還是 agent 搶 CPU,沒查
寫在這裡,不寫成知道了。

十六條紅,重新分過:五列,四種

Day 11 的表用測試名字分。下面這張用 panic 行分:五列,四種。「今天」那欄是收工時的狀態。

紅在哪 為什麼紅(今天證實的) 今天
(a) 12 skill_rpc_skills:簽對的請求回「bad or missing X-Cluster-Auth」 證實是雙重驗章:三個 handler 自己驗章(serve_skillbank.rs:111/177/237),又被 gate_api_and_rpc 包住(serve.rs:242:247-250),不在 SELF_GATED_ROUTESUNGATED_API_ROUTESGUARDED_EXACT_ROUTES、指令表任何一張清單。middleware 先驗、燒掉 nonce;handler 再驗,看到的是重放 紅證測試進 tree(2d062f40),09:52 紅在預期的三行;lib 三行進 f3fcb84f;10:45 targeted 15/15 綠
(b) 2 lib squad_dispatch_tests 兩條 模擬本機呼叫者,沒帶 ConnectInfo;fail-closed 之後 500 變 401。20:10 junit:serve.rs:10219 left 401 right 200、serve.rs:9142 left 401 right 413 補 loopback 地址,兩處,同一個 lib commit;10:45 兩條 PASS
(c) 1 p9_the_shell_cannot_pin_itself 不是 sw.js 的錯:sw.js:38ASSETS167f96c7 起就沒有 /console;紅的是測試對整個 body 做子字串比對,踩到 sw.js:8:30 兩行註解。測試跟 sw.js 同一個 commit 誕生,從沒綠過 修測試:只讀程式碼行;加一條探測本身的紅證(2d062f40);09:52 3/3 綠,10:45 再一次
(d) 1 the_app_only_calls_routes_that_exist 的 call-site 掃描 掃描器看不到 "{}/…",checked=0,撞自己的下限。紅得對 不修,留紅;修好會點名 8 條、3 個檔,只有 3 條等 memory 裁定(下面說)
(e) 1 search_fts5_p99_latency_under_200ms 是 (a) 的第 12 條。 兩份 Day 11 log、20:10 junit、今早的全量、09:52 的紅證,八次 panic 全在 assert_eq!(status, 200) 那行(414db8c4:449,2d062f40 之後是 :452)——第一個請求就 401。這台機器從沒量到 p99。Day 11 寫的「201 ms」是 GitHub CI(#371)的數字 (a) 修好之後 14:31 單獨跑:p99 = 180 ms(min 58、p50 79、max 180),沒 TRY,沒放寬 200 ms

16 條,四種。(e) 那一列留著,因為 Day 11 把它當成一種,今天要把它併回去——而且要用一個數字併回去。

第一種:雙重驗章——先證實,再紅證,然後才修

證實

Day 11 寫「疑似」,因為我沒有去讀那個 handler。今天讓兩個 agent 各自獨立讀,再派駁斥者
預設推翻。結論一致,鏈條是這樣(行號是現在 HEAD 的;f3fcb84fhttp.rs 的清單前面加了
17 行文件,清單往下移):

  1. serve_skillbank.rs:73-75 掛三條路由:/api/skills/api/skills/:id
    /api/skill-timeline
  2. 三個 handler 各自呼叫 require_cluster_auth::111:177:237
  3. serve.rs:242 把它們掛進 router(attach_skill_routes_opt,feature
    experimental-memory,預設開);:247-250 再把 gate_api_and_rpc 這層 middleware
    包在整個 router 外面。
  4. middleware 只對兩種路由站開:SELF_GATED_ROUTES(http.rs:705),和指令表轉出來的路由。三條都不在。
    UNGATED_API_ROUTES(:621)、GUARDED_EXACT_ROUTES(:760)也沒有。
  5. 驗章會燒掉 nonce。http.rs:662 起的註解寫得很清楚:這不是政策清單,是碰撞清單。
    middleware 驗過一次、燒掉;handler 再驗一次,看到的是同一枚 nonce——重放。回的正是
    log 裡那一行 body。

所以不是「疑似」了。這是 Day 9 建 SELF_GATED_ROUTES 要解的那個碰撞,只是 Day 9 的清單
沒看到這三條。

而且家族是 12 條,不是 11。第 12 條是 (e),下面第四種說。

為什麼清單沒看到

SELF_GATED_ROUTES 不是手寫的,有一條測試從原始碼反推它——
api_auth_inventory.rs::the_self_gated_list_matches_the_handlers414db8c4 那版,它反推
的來源是 include_str!("../src/serve.rs"),一個檔案。而三條 skills 路由住在
serve_skillbank.rs

更難堪的是同一個測試檔裡,另一條檢查早就知道了。ROUTE_SOURCES(現在的 :53)
列了兩個檔:serve.rsserve_skillbank.rs;路由 parser 用它,還有一條「parser 要對得上
每一個 .route(」的下限測試守著它。deriver 就在幾百行下面,自己 include_str!
serve.rs,沒讀那份清單。

「清點器只清點它名字裡那片」——Day 10 數到第五次,Day 11 寫「如果證實,這是第六次」。
證實了。第六次。

紅證:三條會紅的測試,先進 tree

母規則:任何檢查先示範會紅才算數。今天 07:03 進 tree 的是 commit 2d062f40,6 個檔、
375 行,只有測試跟設定,lib 原始碼不動——targeted 只 link 這 5 支測試 binary,不是 202 支。
三條預期紅:

一、core/tests/skills_signed_caller_is_admitted.rs(新,213 行)。 一條路由、三個
請求,形狀跟 Day 11 的放行側測試一樣:

  1. 沒簽章的遠端呼叫者(203.0.113.7)打 GET /api/skills?limit=1 → 401/403
    (:149;這一列有走到閘。404/405 代表路由沒掛,200 代表門是開的)
  2. 簽對的呼叫者 → assert_eq!(200),而且 items 是陣列、只有一筆、id 等於種進去
    那一筆、total 是 1、limit 是 1(:165-196;門開了,而且是對的 handler 在回答)
  3. 錯的密鑰簽 → 401(:201-211;那個 200 是簽章換來的,不是某個豁免)

今天預期第 2 個請求紅,body 就是那行「bad or missing X-Cluster-Auth」。?limit=1
故意的:query string 在簽章範圍裡,拿到 200 同時證明 handler 驗的是呼叫者簽的那個
method/path/query。

二、p9_the_gate_closes_without_breaking_local.rs Day 9 那條「簽對的遠端呼叫者打
得到 self-gated 路由」的表,加一列 ("GET", "/api/skills", "")(:288)。旁邊加一條
assert_ne!(404)(:311-313):路由沒掛的話這一列會拿到一個「不是 401」的 404,然後綠
給你看。這一列沒接 skill memory,簽章過了 handler 會回 503;503 也是「不是 401」。今天
預期紅(401)。

三、api_auth_inventory.rs::the_self_gated_list_matches_the_handlers deriver 改成
逐檔讀 ROUTE_SOURCES(迴圈在 :554)。而且加了每檔的下限(:562):一個檔的
production code 裡有 require_cluster_auth(,deriver 卻在它身上一條路由都沒找到,就 panic。
原本只有一個總下限「≥ 20 條」(:585)——serve.rs 自己的 35 條就跨過去了,它看不出少了
一整個檔。今天預期紅,訊息是:

In the source but not the const: ["/api/skill-timeline", "/api/skills", "/api/skills/:id"]

三條的紅,各證一件事:第一條證產品壞了,第二條證 Day 9 的閘測試看得到它,第三條證
清點器現在看得到那個檔。

紅了,紅在預測的那一行

09:31 build lock 空了,紅證 targeted 起跑;09:52 收工(redproof-tests.log):

START 2026-09-05 09:31:13 HEAD=1b7b005b core_clean=0          (第 1 行)
Finished `test` profile in 16m 46s                             (第 132 行)
Starting 30 tests across 5 binaries (1 test skipped)          (第 135 行)
Summary [4.332s] 30 tests run: 15 passed, 15 failed, 1 skipped (第 511 行)
EXIT=100                                                       (第 528 行)
END 2026-09-05 09:52:29                                        (第 529 行)

21 分鐘:編 16m46s(lib 又重編了——lib 原始碼自 414db8c4 起一個字沒動;為什麼,下一篇
「F0」那節)、--list 約 4 分(估:編完到第一條結果,log 沒有時間戳)、實跑 4.3 秒。五條
預測全中:

  • skills-admit 紅:165(log 第 504 行),就是第 2 個請求:「a correctly signed remote
    caller must get 200 from /api/skills?limit=1, got 401 Unauthorized」,body 就是那行
    「bad or missing X-Cluster-Auth」(第 505 行)。第 1、3 個請求沒紅——閘在,路由在。
  • inventory 紅:591(第 151 行),source-not-const 正好三條:/api/skill-timeline
    /api/skills/api/skills/:id(第 153 行);反方向(const 有、source 沒有)是空的。
  • p9 gate 新列紅:316(第 178 行):「/api/skills: a signature the middleware accepted
    was then rejected by the handler as a replay — the nonce was spent twice」。不是 404——路由掛著。
  • p9 shell 3/3 綠(第 184、186、187 行;第三種說)。
  • skill_rpc_skills 仍 12 紅、全部 401(第 514-525 行),latency 那條兩次 TRY 都死在
    :452:9(第 419、440 行)。PERF_RECORD 一行都沒印——12 條沒有一條走到綠的那行。

一件新的:list_endpoint_meets_500ms_perf_gate_for_1000_skills 第一次有 TRY 1 / TRY 2
(第 235、256 行)——nextest filter 放寬生效了(第四種說)。它也死在 401(:404:5,第 250、
271 行),不是 500 ms。

三條紅、一條綠,都在預測的那一行。這一步花 21 分鐘,買到的是「修法動的是對的地方」。

修法:三行,加在清單裡

http.rsSELF_GATED_ROUTES 加三個 pattern(f3fcb84f,現在在 http.rs:718-721):

    // serve_skillbank.rs — feature `experimental-memory`; see the doc above.
    "/api/skill-timeline",
    "/api/skills",
    "/api/skills/:id",

就這樣。handler 自己的驗章不動,middleware 對這三條站開。不放寬任何斷言。理由跟
http.rs:675 附近的註解講的一樣:碰撞的解法是 middleware 退開,不是刪 handler 的檢查——清單裡
有幾條的 handler 驗得比 middleware 嚴(/rpc/admin/shell 不給 loopback 豁免),讓
middleware 接手等於把 node shell 開給任何本機行程。feature 關掉時路由不掛,三個條目惰性,
所以無條件列。

而 deriver 已經在 2d062f40 改成讀 ROUTE_SOURCES。所以這三行進去之後,第三條測試會從
「source 有、const 沒有」翻成綠;三行少一行,它紅回來。清單跟原始碼從此綁在一起,而且
綁的是存在的那幾個檔,不是 deriver 名字裡那個。

補丁附了一段靜態重放:用 Python 逐字重做 deriver 的規則,serve.rs 35 條 +
serve_skillbank.rs 3 條 = 38,const 也是 38。沒編譯、沒跑——那是 09:33 的事。

一個 lib commit,然後翻綠

lib commit 是 f3fcb84f(10:24:22):build.rshttp.rsserve.rsembeddings/mod.rs
skill_wire.rs,加 24 行執行紀錄,共 +153/−15 行。四件事一次 commit——(a) 的三行、(b) 的兩處、
F0 的 build.rs、#371 的 clippy 移植——commit 訊息裡每件附紅證的 log 行,最後一段寫「未驗證:
這個 commit 之後還沒跑任何測試」。git show f3fcb84f -- core/tests 是空的:斷言一個字都沒放寬。

targeted 10:24:33 起跑、10:45:34 收工(targeted-fix.log,HEAD c2a59c5b,tree 乾淨):

Starting 101 tests across 8 binaries (2730 tests and 195 binaries skipped)   (第 280 行)
Summary [15.610s] 101 tests run: 101 passed, 2730 skipped                    (第 383 行)
EXIT=0                                                                        (第 384 行)

21 分鐘:編 15m23s(第 277 行;為什麼一個 -E 過濾的 targeted 要編 15 分鐘,下一篇
「Gatekeeper」那節)、--list 約 5 分(估:10:24:33 → 10:45:34 共 21m01s,減編譯 15m23s、
實跑 15.6s ≈ 5m22s)、實跑 15.6 秒。修之前紅的每一條都綠了:

  • skill_rpc_skills 15/15——12 條 401 家族全部 PASS(第 360-379 行);
  • the_self_gated_list_matches_the_handlers PASS(第 347 行)——const 38、source 38;
  • a_signed_remote_caller_reaches_a_self_gated_route PASS(第 355 行)——/api/skills 那列 200 了;
  • a_signed_caller_is_admitted_to_the_skill_list_with_the_page_it_asked_for PASS(第 380 行)——
    三個請求都對:沒簽被拒、簽對拿到 items 一筆、錯密鑰被拒。

三條紅證翻綠,一條都沒少。第一種收工。

第二種:沒說自己從哪來的本機測試

lib 裡 serve::squad_dispatch_tests 兩條:events_upload_rejects_too_many_parts_with_413
durable_status_survives_restart。它們模擬的是本機呼叫者——同一台機器上的擷取
client、同一台機器上的輪詢器——不簽章,期待 handler 的回答。

Day 11 之前它們拿到 500(沒帶 ConnectInfo,extractor 在 middleware 之前就死)。
414db8c4 之後拿到 401(fail-closed:不知道你從哪來,就不給本機豁免)。紅證的前半
從 20:10 的 junit 讀出來:

serve.rs:9142   left: 401  right: 413
serve.rs:10219  left: 401  right: 200

是 401,不是別的。這一步不能省——如果它們紅的是 500 或 403,「補一個 loopback 地址」
就不是對的修法。

修法是兩處 request builder 各加一個 extension:ConnectInfo(127.0.0.1:51000)。一處在
post_event_n_parts 那個 helper(兩個請求都走它),一處在輪詢 /rpc/task/status/{id}
地方;f3fcb84f 之後在 serve.rs:9112:10229 那兩行 51000。不用加 Origin——worker 讀
auth_gate.rsorigin_permits_local_exemption,None 直接回 true,駁斥者複核過;
我沒抽查。

這兩處改的是 serve.rs 裡的 #[cfg(test)] 模組。對 cargo 來說那是 lib 變了:202 支重
link。所以它跟 (a) 的三行併一個 commit。兩處各七、八行的修法,單獨跑一次全量要付一整輪的稅。

紅證的後半在 10:45:durable_status_survives_restart PASS 0.033 s(targeted-fix.log:319)、
events_upload_rejects_too_many_parts_with_413 PASS 0.027 s(:322)。413 跟 200 各回到自己的
位置。第二種收工。

第三種:探測讀到自白

Day 11 的表寫:「/sw.js 又把 HTML shell 放進 precache——Day 9 的 p9 測試,今天沒動。」

錯。而且錯的方式很具體。

core/src/web/sw.js:38:

const ASSETS = ['/manifest.webmanifest', '/icon-180.png', '/icon-192.png', '/icon-512.png'];

沒有 /consolegit log 對這個檔只有三個 commit:6e5637dd(07-02,原版,
const SHELL = ['/console', …])、1518fad7(08-06 改名)、167f96c7(09-03,Day 10 重寫
成 network-first)。167f96c7 到 HEAD,byte-identical。/console 從 Day 10 離開 precache
之後,一次都沒回來。

那測試紅在哪?414db8c4 那版第 97-98 行的探測是:

!body.contains("'/console'") && !body.contains("\"/console\"")

整個 body 找子字串。而 sw.js 開頭有一段「WHAT WENT WRONG HERE」——它用註解交代
自己以前怎麼錯。第 8 行:// The first version precached '/console' under a hand-written cache name;第 30 行:// 中文: 舊版把 '/console' 用手寫的 cache 名字 cache-first 快取住
兩個命中,都是註解。

探測把自白讀成再犯。

再往回看一步:167f96c7 同一個 commit,既重寫了 sw.js(把 /console 從程式碼搬進註解),
也新增了這條測試。測試從出生那一刻就紅,從沒綠過。Day 11 說「Day 9 的測試,今天沒動」——
它沒動是真的,它從來沒對過也是真的。三份紀錄(Day 11 兩份 log、今早全量)都死在同一行
:97:5

修測試,不修 sw.js

Day 9 的意圖沒錯:cache 名字要綁 build、HTML 不能 cache-first、signer 不能被 cache。
現在的 sw.js 三件都做到了。錯的是探測,所以改探測(在 2d062f40):

  • code_only()(p9_the_shell_cannot_pin_itself.rs:78):剝掉整行// 的行,
    其他一個字不動。行尾註解不剝——一條真的 precache 項目藏不住,錯也錯在安全的方向。
  • shell 那條測試全部改讀 code(:148)。
  • 加一條探測本身的紅證:the_probe_reads_code_not_confessions(:89)。原版那行
    const SHELL = ['/console', …] 必須抓得到;只有註解提到 '/console' 的必須抓不到;而且
    剝完 const ASSETS 還要在——證明剝掉的是註解,不是程式碼。

一條會抓錯東西的探測,跟一條什麼都抓不到的探測,是同一種東西。所以這條紅證不是可有可無:
它是這個測試第一次證明自己看得見。

09:52 跑過,3/3 綠(redproof-tests.log 第 184、186、187 行):the_probe_reads_code_not_confessions
0.015 s、the_html_shell_is_not_served_cache_first 0.025 s、the_cache_name_is_bound_to_this_build
0.026 s。10:45 lib commit 之後再跑一次,還是 3/3(targeted-fix.log:354/356/357)。sw.js 一個字沒改。
第三種收工。

第四種(原 (e)):從沒量到的 latency,今天第一次量到

Day 11 寫了兩句:

時間斷言,兩次嘗試都超過 200 ms——跟 main 第四層同一條測試,這次量到的是 syspolicyd
佔著 40% CPU 的這台 Mac。

search_fts5_p99_latency | 機器,不是程式

兩句都錯。今天派一個 agent 去讀兩份 Day 11 log 跟 20:10 的 junit,駁斥者再讀一次;
早上全量收工再多一份,09:52 紅證又多一份:

full21-before.log:4029   TRY 1 … panicked at tests/skill_rpc_skills.rs:449:9
full21-before.log:4050   TRY 2 … panicked at tests/skill_rpc_skills.rs:449:9
targeted-after.log:3327  TRY 1 → :449:9
targeted-after.log:3348  TRY 2 → :449:9
full22-after.log:3755    TRY 1 → :449:9
full22-after.log:3776    TRY 2 → :449:9
redproof-tests.log:419   TRY 1 → :452:9   (2d062f40 在上面加了三行)
redproof-tests.log:440   TRY 2 → :452:9

八次 panic,同一行。那一行是(現在的 :452):

assert_eq!(status, StatusCode::OK, "query {uri} failed: {body}");

它在那個跑 100 次查詢的迴圈裡面。第 0 次查詢(q=token42)就拿到 401,body 是同一行
「bad or missing X-Cluster-Auth」。算 p99 的那幾行(:460-464)一次都沒跑到。八次 panic
實測落在 0.846 到 1.046 秒之間——一條要種 1000 筆、打 100 次的測試,一秒就死了,它根本
沒開始量。

所以它不是「時間斷言量到機器」。它是 (a) 的第 12 條,同一個 handler、同一個雙重驗章。
skill_rpc_skills 這個 binary 15 條測試,junit 說 12 條紅,12 條 body 全是 401——Day 11 的
表寫「11 條是另一件事」,少的那一條就是它。

隔離設定有沒有生效,順便查到另一條

原本 Phase D 要先確認 nextest.tomlserial-latency 群組對這條有生效。有:filter
test(/latency|_p99_|under_\d+ms/) 對這個名字命中三次;而 TRY 1 / TRY 2 這兩行只會出現
retries = 1 生效的測試上,retries 只寫在那個 override 裡(nextest.toml:49)——三份 log
只有它有 TRY 行。重試本身就是它在群組裡的證據。

順便查到另一條:list_endpoint_meets_500ms_perf_gate_for_1000_skills 也斷言時間(500 ms,
skill_rpc_skills.rs:411),但名字裡沒有 latency_p99_under_\d+ms 任何一個,
filter 沒命中它,它一直排在平行池裡跑。filter 補了 meets_\d+ms|_perf_gate_
(nextest.toml:39);09:52 的紅證裡它第一次有 TRY 1/TRY 2,放寬生效。它今天也是 12 條裡
的一條——一樣死在 401(:404:5),不是 500 ms;所以它也從沒量到過。

還有一件:這兩條測試綠的時候什麼數字都不留,只有紅的時候才印。改成綠也 eprintln!
一行 PERF_RECORD …(skill_rpc_skills.rs:409:460),綠燈紀錄要能寫「p99 = 幾 ms」,
不能只寫「在預算內」。

14:31,第一次量到

10:45 的 targeted 裡它們已經綠了——search_fts5_p99 PASS 12.131 s、list_endpoint PASS 0.938 s
(targeted-fix.log:373:365),兩條都沒有 TRY。但 nextest 預設不印綠測試的 stderr,PERF_RECORD
那行沒進 log。所以 Phase D 照原計畫單獨再跑一次,加 --success-output immediate
(phaseD-latency.log,HEAD c2a59c5b,14:31:29 → 14:32:00):

Finished `test` profile in 0.70s                                          (第 132 行;lib 沒重編——F0 生效)
Starting 2 tests across 1 binary (13 tests skipped)                       (第 135 行)
PASS [ 0.968s] list_endpoint_meets_500ms_perf_gate_for_1000_skills        (第 136 行)
    PERF_RECORD list_endpoint_1000_skills elapsed_ms=11                   (第 145 行)
PASS [12.075s] search_fts5_p99_latency_under_200ms_with_1000_rows         (第 147 行)
    PERF_RECORD search_fts5_p99 p99_ms=180 min_ms=58 p50_ms=79 max_ms=180 (第 156 行)
Summary [13.044s] 2 tests run: 2 passed, 13 skipped                       (第 159 行)
EXIT=0

p99 = 180 ms。 200 ms 的預算沒動(f3fcb84f 沒碰 nextest.toml,也沒碰測試檔)。機器狀態:
執行紀錄 14:32 那列寫 load 1.69、syspolicyd 0%、六個 subagent 剛起跑;我沒另外量。list 那條 11 ms
對 500 ms。

所以「機器,不是程式」這句,在這台 Mac 上從來沒成立過——不是因為今天過了,是因為它以前根本
沒量到 p99。今天過了,而且是在有 agent 在跑的白天過的。這一句以後如果要寫,要有一個 PERF_RECORD
在後面。

同一支 binary 在 GitHub 上:十四綠、一紅

那「201 ms」哪來的?W-F 把 PR #371(fix/clippy-1-98-as-chunks → main,head c1ae414c,
main 是 892518cf)最後一組 21 個 check 的 log 逐個讀了(tick4/pr371-checks.md)。三件事。

一、雙重驗章在 main 上不存在。 CI Fast 的 Cargo Tests 用 cargo test -- --test-threads=1
跑同一支 skill_rpc_skills:15 條,14 過 1 紅(pr371-checks.md:114)。紅的那條就是
search_fts5_p99_latency,panic 在 :454:5——p99 那行,不是 :449:「got p99=201ms
(min=62ms max=205ms)」(:109-110)。:449assert_eq!(status, 200) 在 CI 過了,
100 個查詢全部 200(:121)。為什麼?gate_api_and_rpcSELF_GATED_ROUTES
origin/maincore/src 裡 0 處;它們是 dev 分支 Day 9 加的。main 沒有那層 middleware,
就沒有碰撞。所以 Day 11 那句「第四層量到機器」對 CI 是對的——共用的 ubuntu runner、序列跑、
201 對 200;錯的是我拿它解釋這台 Mac 的紅。兩台機器,兩行:這裡 :449,那裡 :454
今天這台量到 180,CI 那台量到 201——同一條測試、同一個 200 ms 預算,一台過一台不過,這才是
「機器」該有的形狀。整組 21 個 check 裡,(a) 的 401、(b) 的 ConnectInfo 500 一次都沒出現(:250)。

二、Integration Tests 紅在腳本自己的矛盾。 Day 11 寫「Integration Tests 也紅著,今天沒看」。
看了:scripts/integration-test.sh 40 條,38 過 2 紅——/rpc/task/assign auth: expected 401, got 202no-auth: expected 401, got 200(:40-41)。腳本第 64 行用
SPECTYN_ALLOW_EMPTY_CLUSTER_SECRET=1 起 daemon,agents.toml 沒有 cluster_secret;
auth_gate.rs:412-417 看到這個環境變數、又沒設密鑰,在 HMAC 檢查之前就回 Ok(403 訊息
:419-425)。而同一支腳本第 211-231 行斷言壞 token 跟沒 header 都要 401。兩段是不同
時候寫的:401 斷言是 cc789fb3(04-23),空密鑰豁免是 403915d4(06-12,closes #326)。
兩者從建構上互斥,所以這個 job 在每個 PR 上都紅——#371 第一個 commit 一樣,姊妹 PR
deps/h2-rustsec-2026-0258 一樣(:92);它主要在 PR 上跑(ci-medium.yml:
on: pull_request: branches: [main],另外還掛了 workflow_dispatch 可以手動觸發跑三平台
矩陣),main 上沒有紀錄可比(除非有人手動觸發過)。這不是 (a),不是 401——它是一道被自己的
環境變數繞過的閘,跟一條堅持要 401 的斷言,住在同一個檔裡。修法歸 PR 那邊,今天不碰 #371。

三、其他八個紅,沒有一個是這個 PR 造成的。 h2 0.4.14 的 RUSTSEC-2026-0258(Ship Gate
跟 Dependency Audit,PR #369『deps/h2-rustsec-2026-0258』在修——這條查的是 gh pr view 369,
不在 pr371-checks.md 裡)、gitleaks 的 token 權限、GHAS 沒開、runner 磁碟滿、Android
pty_bridge 沒 cfg-gate(08-08 起 main 就紅)、scenario harness 寫 [cluster] secret =
struct 欄位叫 cluster_secretskillbank 兩條測試的 env-var 競態(--test-threads=1
就過)。除了 #369 那條,其餘每一個在 pr371-checks.md 有 log 行號。Day 11 第八盞燈「剝掉一層紅,
下面還有一層」——剝到第四層停;今天知道第四層是 CI 真的,而旁邊還有一整排跟這個 PR 無關、
比它老的紅。

留一條紅:route ratchet 與 memory 的裁定

16 條紅裡有 1 條,今天決定不修。它是 core/tests/the_app_only_calls_routes_that_exist.rs
every_daemon_path_the_app_builds_is_served(第 115 行)。這一節寫它為什麼紅、修好會發生
什麼、為什麼還是留著。

它掃什麼

檔案裡有兩條測試。第一條(第 75 行)檢查 endpoint map:daemon_api.rsdaemon_route()
承諾的每條路徑,活著的 spectyn serve 都要有。那條今天是綠的。

第二條是 call-site 掃描。先建「活路由表」:serve.rsserve_skillbank.rs 裡每一個
.route("…"),加上 commands/http.rs 那張表轉出來的 /api/<a>/<b>。表要超過 80 條,不然
它先說「這個檢查沒有在看它以為在看的東西」(第 117-121 行)。重現出來是 118 條:serve.rs
97 + serve_skillbank.rs 3 + table_paths() 18。

然後讀 app/src-tauri/src/commands/ 底下每個 .rs。只看跟 hub_urlapi_url( 寫在
同一行的字串(第 142 行;api_url( 在 app 裡 0 處,是死字);字串要以 / 開頭(第 146 行);
路徑段落裡有 {} 的換成 :id(第 153 行);拿去對活路由表。每對一次 checked 加一
(第 156 行)。最後兩條斷言:至少對過 1 條(第 171-172 行),而且沒有對不上的。

它自己的註解說它的工作是什麼(第 164-165 行):2026-09-03 之後 call site 都改走 endpoint
map,所以這條掃描不清點全部,它抓繞過那張表的人

為什麼今天 checked=0

app 端組 URL 的寫法是這樣(memory.rs:25):

let url = format!("{}/memory/observations", config.hub_url);

這一行有 hub_url,過得了第 142 行。字串是 {}/memory/observations,第一個字是 {,
不是 /。第 146 行跳過它。

commands/ 底下跟 hub_url 同行的字串,每一個都以 { 開頭({}/…{}{}),或者根本
不是路徑。以 / 開頭的:0。所以 checked 是 0,撞第 171 行:

一條跟 hub_url 同行的字面路徑都沒有 —— 這條掃描已經對不上程式碼的形狀了

它紅得對。第 153 行知道路徑中間會有 {},會換成 :id;但開頭那個 {}——放 hub_url
那個——在第 146 行就被丟掉,走不到第 153 行。Day 10 寫「留成 404,由 CI 每次指名它」;
Day 11 已經勘誤:它從來沒有指名過任何東西,因為它先撞自己的下限。一個專門抓「繞過那張表的
人」的掃描器,看不到任何一個繞過那張表的人。

修好會點名什麼

修法是一行:在比對 / 之前,先把開頭的 {} 剝掉。不剝,第 153 行的規則也接不上——
{}/memory/observations 會被正規化成 :id/memory/observations,少一個開頭的 /,一樣
對不上。

沒有跑。worker 跟駁斥者各自用 Python 逐行重做掃描器的規則(tick4/phaseE-offenders.md
tick4/we_scan_replica.out),數字一致:現況 checked 0;剝掉 {} 之後 checked 12、對不上 8
對得上的 4 條:cluster.rs:25/api/nodeshealth.rs:61/api/statushealth.rs:93
/api/dashboard/statusprovider.rs:204/api/providers/health。對不上的 8 條:

檔案:行 字串 誰有這條路 等誰
memory.rs:25 "{}/memory/observations" 只有 core/src/main.rs:244 D-memory
memory.rs:54 "{}/memory/observations/stats" 只有 core/src/main.rs:245 D-memory
memory.rs:73 "{}/memory/observations" 只有 core/src/main.rs:244 D-memory
agent.rs:19 "{}/agent/{}/run" 只有 core/src/main.rs:247;一個活呼叫者 AgentsPanel.tsx:123 沾 D-second-daemon
agent.rs:45 "{}/hand/{}/run" 沒有(main.rs:242 只有 /hands);零呼叫者 不等誰
networking.rs:13 "{}/networking/discovered" 沒有;零呼叫者;檔案 04-10 之後沒人動 不等誰
networking.rs:32 "{}/networking/routes" 同上 不等誰
networking.rs:51 "{}/networking/status" 同上(daemon_api.rs:216-217,290 說它在 main.rs,過期) 不等誰

再多一步——行閘也認 {hub}——會多第 9 條:cluster_peers.rs:415/rpc/events,整個
core 沒這條路由,前端 useClusterPeers.ts:170.catch 吞掉。

所以 Day 11 跟今天早上的目標檔寫的「修好會點名 memory.rs 三條」少算了。是 8 條、3 個
檔案
,而且只有 3 條等 memory 的裁定。另外 5 條:1 條沾第二 daemon 那一案,4 條哪個
daemon 都沒有、誰都沒在叫——技術上今天就能刪。但「先清 5 條,讓這條測試紅得剛好只剩 memory.rs
三行」也是一個裁定,不代裁。

「只有 main.rs 有」也要說清楚。main.rs:341-346 那兩個 handler 是 stub:/memory/observations
{"observations": []},/stats{"total_observations": 0}。就算那個 daemon 有人跑,
app 拿到的也是空的。

這三條今天在 app 裡是什麼

三條指令都在 lib.rs:1344-1346 註冊。誰在叫:

  • MobileMemory.tsx:98-99、115-116、138:三條都叫。檔頭第 6-7 行寫「no backend change」。
  • MemoryPanel.tsx:307:叫 search_memory。它的清單那一半已經不走這三條——第 165-170 行
    的註解自己說了 /memory/… 這個 daemon 沒有、有的是 /rpc/recall,然後用
    fetchConcept("memory") 走 endpoint map(daemon_route("memory")/rpc/recall,
    daemon_api.rs:251)。
  • 瀏覽器模式的 shim tauri-compat.ts:135-138、252-255:直接 GET /memory/observations,
    然後 .catch(() => ({ observations: [] }))。404 變成空清單。這正是測試自己的錯誤訊息
    警告的形狀:「如果呼叫點把錯誤吞掉,連 404 都看不到」。
  • tauri-compat.mesh-gaps.test.ts:18-21:一條 vitest,名字叫「search_memory hits the real
    observations route」,fetch 是 stub,任何 URL 都回 ok: true。它綠,而它看不到後端。

三條的驗章也不一樣:get_memory_observationssearch_memory 用 bearer(memory.rs:38
77),get_memory_stats 用簽章(memory.rs:55-61)。Day 10 的 handoff 說「memory 還有
direct bearer」,就是這兩條。

(以上 app 端的行號是 agent 的靜態讀取;我只抽查了 memory.rsagent.rs
networking.rs 那 8 行跟 main.rs 的路由與 stub,其餘未抽查。)

為什麼不 allowlist

要讓這條測試今天就綠、不用裁定,有三種做法:在掃描器裡加一個「/memory/* 已知,跳過」的
清單;把下限從 1 降到 0;或把 /memory/observations 悄悄改指到 /rpc/recall。三種是同一
件事:告訴一條 ratchet 哪些紅不要報。

測試檔自己第 72-73 行寫了為什麼不行:「Its floor assertion caught that; without the floor
it would have reported clean while seeing almost nothing.」Day 10 也寫過:沒有那條下限,
它會報綠,一路綠下去,而它其實只看到一條。allowlist 是同一個東西的另一個形狀——一條 ratchet
的意義是「程式碼的形狀變了它會紅」;加了 allowlist,它的意義變成「除了我們決定不看的那些,
其他變了會紅」。那不是量測,是宣告。第三種做法 Day 10 已經拒絕過:把一個 404 換成一個形狀
不對的 200,比 404 更難發現。

所以留紅。綠燈紀錄那一行照實寫:1 紅,route ratchet,等裁定。

三個選項,各自的代價

選項 動到什麼 代價 做完之後掃描器
memory.rs(85 行)、lib.rs:1344-1346mod.rs:25;MobileMemory.tsx 三個呼叫點、MemoryPanel.tsx:307 的搜尋框、tauri-compat.ts 兩處 case、mesh-gaps.test.ts:18-21 那條測試 手機版 Memory 面板失去 stats、清單、搜尋三樣;桌面版只失去搜尋框(清單已走 /rpc/recall) memory 三條消失;剩 agent 2 + networking 3
/rpc/recall,誠實改名 search_memory 改名(例如 recall_memory),走 daemon_route("memory") + send_signed POST {query, limit};前端把 observations 改讀 hits 只接得上 1 條。get_memory_observations(不帶 query 的清單)跟 get_memory_statsserve.rs 沒有對應。這兩條回到「刪」或「蓋」。改名是讓讀的人知道拿到的是語意召回,不是觀察清單 memory 剩 0-2 條,看另外兩條怎麼裁;agent/networking 同上
蓋後端 serve.rs/memory/observations/stats 兩條 .route(、handler、驗章、測試 沒有東西可以搬:main.rs:341-346 是 stub。這是定義一個新功能——「觀察」是什麼、存哪裡、要不要像 recall 一樣去識別化——不是修一個 bug。08-06「只有一個後端」的裁定說它要蓋在 serve 上,不是把 main.rs 拉回來 memory 三條變綠;agent/networking 同上

三個選項共用的第一步都一樣:先修掃描器,而且先紅證——修之前跑一次,拿到那句「一條跟 hub_url
同行的字面路徑都沒有」;修之後跑一次,拿到 8 條名字。第二次的紅才證明它看得到東西。然後
才按裁定處理三條。這一步今天不做:改它要重建 1 支 binary、再過一次 Gatekeeper;而沒有裁定,
修好也只是把一種紅換成另一種紅。

寫這篇的時候,D-memory 還在等操作者。下一篇從全量收工那一刻開始寫。


上一篇
Day 11:從來沒紅過的燈,不是綠燈
下一篇
Day 12(下):讓那盞燈有人看
系列文
從單一agent 到多agent 集群的開發流水帳以及應用18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言